Skip to content

fix(agent-server): detect all secret-bearing fields for the plaintext-save warning, not just llm.api_key - #4618

Merged
simonrosenberg merged 5 commits into
mainfrom
require-secret-key
Aug 26, 2026
Merged

fix(agent-server): detect all secret-bearing fields for the plaintext-save warning, not just llm.api_key#4618
simonrosenberg merged 5 commits into
mainfrom
require-secret-key

Conversation

@simonrosenberg

@simonrosenberg simonrosenberg commented Aug 24, 2026

Copy link
Copy Markdown
Member

HUMAN:

Split this out of OpenHands/OpenHands#15722 after closing it. Simplified after two more rounds of scrutiny caught that the original scope was overbuilt — see "Why" for the full trail. What's left is a single, narrow bug fix.


AGENT:

Why

This started as two things: a new opt-in require_secret_key flag to hard-fail instead of silently persisting secrets in plaintext, plus a fix to the check that decides when to warn about plaintext storage. The flag didn't survive scrutiny; the fix does.

What got dropped, and why. The flag's justification was "closes a gap for multi-tenant/Cloud deployments" — that didn't hold up: AgentServerConfig._default_secret_key() already falls back to SESSION_API_KEY/OH_SESSION_API_KEYS_0 as the cipher key, and OpenHands's own Cloud runtime-api already sets SESSION_API_KEY unconditionally on every pod, so cipher is already non-None on every real Cloud runtime regardless of this flag. Reframed as a general SDK opt-in for third-party deployers instead — but that didn't hold up either: anyone sophisticated enough to discover and enable OH_REQUIRE_SECRET_KEY already understands the tradeoff well enough to just set OH_SECRET_KEY directly, which is strictly simpler and gets the same result. There's no realistic operator in between "doesn't know/care about encryption keys" and "knows enough to want fail-closed behavior but not enough to just configure the key." The flag was a second forgettable switch guarding against forgetting the first one — same failure mode, not a fix for it. Dropped entirely: Config.require_secret_key, the require_cipher constructor params and raise branches on both file stores, _get_require_cipher. (The matching Cloud-wiring PR, runtime-api#722, was closed for the same reason before this SDK PR was simplified.)

What's left: the bug fix. Independent of the flag entirely, the existing "saving secrets in plaintext" warning was gated on llm_api_key_is_set — one hardcoded field. Any other secret-bearing field (verification.critic_api_key, MCP server env/headers, agent_context.secrets) got persisted in plaintext with zero warning, under plain default behavior, before this PR touched anything.

Fixed via has_any_secret (on both PersistedSettings and Secrets): a probe cipher passed as context={"cipher": probe} forces every secret field's own field_serializer down its real "encrypted" branch, so detection reuses the actual serialization logic instead of a hand-maintained list of "known" secret fields — the kind of list that's exactly how the original gap happened as MCP/critic/agent_context secrets were added over time without anyone updating the warning check. A naive isinstance(v, SecretStr) tree-walk was tried first and rejected — it misses agent_context.secrets, whose bare-string values are plain str at rest and only become secret-shaped inside their own field serializer at dump time.

Summary

  • _contains_secret_value/has_any_secret: generic secret detection via a probe cipher through the real serialization pipeline, replacing the llm_api_key_is_set-only check that gated the plaintext-save warning.
  • No new config surface, no new opt-in behavior, no behavior change for any existing deployment beyond: the plaintext warning now fires correctly for secret types it previously missed.

REST API contract changes

Compared with base OpenAPI 6d3881035982 for public /api/** paths.

--- base public OpenAPI
+++ head public OpenAPI
@@ -3,0 +4 @@
+operation DELETE /api/canvas-extensions/installed/{extension_name} operationId=uninstall_canvas_extension_endpoint_api_canvas_extensions_installed__extension_name__delete
@@ -17,0 +19,3 @@
+operation GET /api/canvas-extensions/installed operationId=list_installed_canvas_extensions_endpoint_api_canvas_extensions_installed_get
+operation GET /api/canvas-extensions/installed/{extension_name} operationId=get_installed_canvas_extension_endpoint_api_canvas_extensions_installed__extension_name__get
+operation GET /api/canvas-extensions/installed/{extension_name}/bundle operationId=get_canvas_extension_bundle_endpoint_api_canvas_extensions_installed__extension_name__bundle_get
@@ -63,0 +68 @@
+operation PATCH /api/canvas-extensions/installed/{extension_name} operationId=set_canvas_extension_enabled_endpoint_api_canvas_extensions_installed__extension_name__patch
@@ -76,0 +82 @@
+operation POST /api/canvas-extensions/install operationId=install_canvas_extension_endpoint_api_canvas_extensions_install_post
@@ -124,0 +131 @@
+parameter DELETE /api/canvas-extensions/installed/{extension_name} path:extension_name required=true schema=type="string" minLength=1 maxLength=255 pattern="^[a-z0-9]+(?:-[a-z0-9]+)*$"
@@ -143,0 +151,2 @@
+parameter GET /api/canvas-extensions/installed/{extension_name} path:extension_name required=true schema=type="string" minLength=1 maxLength=255 pattern="^[a-z0-9]+(?:-[a-z0-9]+)*$"
+parameter GET /api/canvas-extensions/installed/{extension_name}/bundle path:extension_name required=true schema=type="string" minLength=1 maxLength=255 pattern="^[a-z0-9]+(?:-[a-z0-9]+)*$"
@@ -205,0 +215 @@
+parameter PATCH /api/canvas-extensions/installed/{extension_name} path:extension_name required=true schema=type="string" minLength=1 maxLength=255 pattern="^[a-z0-9]+(?:-[a-z0-9]+)*$"
@@ -248,0 +259 @@
+requestBody PATCH /api/canvas-extensions/installed/{extension_name} application/json required=true schema=UpdateCanvasExtensionStateRequest
@@ -258,0 +270 @@
+requestBody POST /api/canvas-extensions/install application/json required=true schema=InstallCanvasExtensionRequest
@@ -296,0 +309,3 @@
+response DELETE /api/canvas-extensions/installed/{extension_name} 200 application/json schema=UninstallCanvasExtensionResponse
+response DELETE /api/canvas-extensions/installed/{extension_name} 404 no-content
+response DELETE /api/canvas-extensions/installed/{extension_name} 422 application/json schema=HTTPValidationError
@@ -327,0 +343,7 @@
+response GET /api/canvas-extensions/installed 200 application/json schema=InstalledCanvasExtensionsListResponse
+response GET /api/canvas-extensions/installed/{extension_name} 200 application/json schema=InstalledCanvasExtensionResponse
+response GET /api/canvas-extensions/installed/{extension_name} 404 no-content
+response GET /api/canvas-extensions/installed/{extension_name} 422 application/json schema=HTTPValidationError
+response GET /api/canvas-extensions/installed/{extension_name}/bundle 200 application/json schema={}
+response GET /api/canvas-extensions/installed/{extension_name}/bundle 404 no-content
+response GET /api/canvas-extensions/installed/{extension_name}/bundle 422 application/json schema=HTTPValidationError
@@ -410,0 +433,3 @@
+response PATCH /api/canvas-extensions/installed/{extension_name} 200 application/json schema=UpdateCanvasExtensionStateResponse
+response PATCH /api/canvas-extensions/installed/{extension_name} 404 no-content
+response PATCH /api/canvas-extensions/installed/{extension_name} 422 application/json schema=HTTPValidationError
@@ -439,0 +465,4 @@
+response POST /api/canvas-extensions/install 200 application/json schema=InstalledCanvasExtensionResponse
+response POST /api/canvas-extensions/install 400 no-content
+response POST /api/canvas-extensions/install 409 no-content
+response POST /api/canvas-extensions/install 422 no-content
@@ -1049,0 +1079,14 @@
+schema CanvasExtensionContributes property pages optional schema=type="array" items=CanvasExtensionPage
+schema CanvasExtensionContributes type="object"
+schema CanvasExtensionManifest property contributes optional schema=CanvasExtensionContributes
+schema CanvasExtensionManifest property description optional schema=type="string" default=""
+schema CanvasExtensionManifest property display_name required schema=type="string"
+schema CanvasExtensionManifest property entrypoint required schema=type="string"
+schema CanvasExtensionManifest property name required schema=type="string"
+schema CanvasExtensionManifest property schema_version required schema=type="integer"
+schema CanvasExtensionManifest property version required schema=type="string"
+schema CanvasExtensionManifest type="object"
+schema CanvasExtensionPage property id required schema=type="string"
+schema CanvasExtensionPage property path required schema=type="string"
+schema CanvasExtensionPage property title required schema=type="string"
+schema CanvasExtensionPage type="object"
@@ -1477,0 +1521,5 @@
+schema InstallCanvasExtensionRequest property force optional schema=type="boolean" default=false
+schema InstallCanvasExtensionRequest property ref optional schema=anyOf=[type="string",type="null"]
+schema InstallCanvasExtensionRequest property repo_path optional schema=anyOf=[type="string",type="null"]
+schema InstallCanvasExtensionRequest property source required schema=type="string" minLength=1
+schema InstallCanvasExtensionRequest type="object"
@@ -1487,0 +1536,13 @@
+schema InstalledCanvasExtensionResponse property description optional schema=type="string" default=""
+schema InstalledCanvasExtensionResponse property enabled optional schema=type="boolean" default=false
+schema InstalledCanvasExtensionResponse property install_path required schema=type="string"
+schema InstalledCanvasExtensionResponse property installed_at required schema=type="string"
+schema InstalledCanvasExtensionResponse property manifest optional schema=anyOf=[CanvasExtensionManifest,type="null"]
+schema InstalledCanvasExtensionResponse property name required schema=type="string"
+schema InstalledCanvasExtensionResponse property repo_path optional schema=anyOf=[type="string",type="null"]
+schema InstalledCanvasExtensionResponse property resolved_ref optional schema=anyOf=[type="string",type="null"]
+schema InstalledCanvasExtensionResponse property source required schema=type="string"
+schema InstalledCanvasExtensionResponse property version optional schema=type="string" default=""
+schema InstalledCanvasExtensionResponse type="object"
+schema InstalledCanvasExtensionsListResponse property canvas_extensions required schema=type="array" items=InstalledCanvasExtensionResponse
+schema InstalledCanvasExtensionsListResponse type="object"
@@ -2574,0 +2636,2 @@
+schema UninstallCanvasExtensionResponse property message required schema=type="string"
+schema UninstallCanvasExtensionResponse type="object"
@@ -2578,0 +2642,5 @@
+schema UpdateCanvasExtensionStateRequest property enabled required schema=type="boolean"
+schema UpdateCanvasExtensionStateRequest type="object"
+schema UpdateCanvasExtensionStateResponse property enabled required schema=type="boolean"
+schema UpdateCanvasExtensionStateResponse property name required schema=type="string"
+schema UpdateCanvasExtensionStateResponse type="object"

Issue Number

Fixes #4609 (scoped down from its original ask — see "Why" and the issue's own resolution note).

How to Test

from openhands.agent_server.persistence import FileSettingsStore, PersistedSettings

store = FileSettingsStore(persistence_dir="/tmp/x")
settings = PersistedSettings.model_validate(
    {"agent_settings": {"verification": {"critic_api_key": "sk-test"}}}
)
store.save(settings)  # now logs the PLAINTEXT warning; previously logged nothing,
                      # even though the file was written unencrypted either way

Test coverage: tests/agent_server/test_persistence_secret_detection.py (11 tests) — has_any_secret detection across llm.api_key, critic_api_key, MCP server secrets, agent_context.secrets, and the empty case, for both PersistedSettings and Secrets; plus behavioral tests confirming the warning now fires for non-llm.api_key secrets (regression coverage for the actual bug) and stays silent when no secrets are present.

Ran locally:

  • pytest tests/agent_server/test_persistence_secret_detection.py — 11/11 passed
  • pytest tests/agent_server/test_settings_router.py tests/agent_server/test_profiles_router.py tests/agent_server/test_agent_profiles_router.py tests/agent_server/test_credential_binding.py tests/agent_server/test_mcp_oauth_store.py — 284/284 passed
  • pre-commit run --files <changed files> — clean
  • .github/scripts/check_persisted_settings_compat.py — passes
  • .github/scripts/check_agent_server_rest_api_breakage.py / check_sdk_api_breakage.py — no breaking changes (the removed flag/params were never in a released version)

🐳 Agent Server images for this PR — GHCR package, pull/run commands, and all pushed tags (click to expand)

GHCR package: https://github.com/OpenHands/agent-sdk/pkgs/container/agent-server

Variants & Base Images

Variant Architectures Base Image Docs / Tags
java amd64, arm64 eclipse-temurin:17-jdk Link
python amd64, arm64 nikolaik/python-nodejs:python3.13-nodejs22-slim Link
golang amd64, arm64 golang:1.21-bookworm Link

Pull (multi-arch manifest)

# Each variant is a multi-arch manifest supporting both amd64 and arm64
docker pull ghcr.io/openhands/agent-server:b481a03-python

Run

docker run -it --rm \
  -p 8000:8000 \
  --name agent-server-b481a03-python \
  ghcr.io/openhands/agent-server:b481a03-python

All tags pushed for this build

ghcr.io/openhands/agent-server:b481a03-golang-amd64
ghcr.io/openhands/agent-server:b481a038f6e9924eef4a66d04fdbedda95ecd915-golang-amd64
ghcr.io/openhands/agent-server:require-secret-key-golang-amd64
ghcr.io/openhands/agent-server:b481a03-golang_tag_1.21-bookworm-amd64
ghcr.io/openhands/agent-server:b481a03-golang-arm64
ghcr.io/openhands/agent-server:b481a038f6e9924eef4a66d04fdbedda95ecd915-golang-arm64
ghcr.io/openhands/agent-server:require-secret-key-golang-arm64
ghcr.io/openhands/agent-server:b481a03-golang_tag_1.21-bookworm-arm64
ghcr.io/openhands/agent-server:b481a03-java-amd64
ghcr.io/openhands/agent-server:b481a038f6e9924eef4a66d04fdbedda95ecd915-java-amd64
ghcr.io/openhands/agent-server:require-secret-key-java-amd64
ghcr.io/openhands/agent-server:b481a03-eclipse-temurin_tag_17-jdk-amd64
ghcr.io/openhands/agent-server:b481a03-java-arm64
ghcr.io/openhands/agent-server:b481a038f6e9924eef4a66d04fdbedda95ecd915-java-arm64
ghcr.io/openhands/agent-server:require-secret-key-java-arm64
ghcr.io/openhands/agent-server:b481a03-eclipse-temurin_tag_17-jdk-arm64
ghcr.io/openhands/agent-server:b481a03-python-amd64
ghcr.io/openhands/agent-server:b481a038f6e9924eef4a66d04fdbedda95ecd915-python-amd64
ghcr.io/openhands/agent-server:require-secret-key-python-amd64
ghcr.io/openhands/agent-server:b481a03-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim-amd64
ghcr.io/openhands/agent-server:b481a03-python-arm64
ghcr.io/openhands/agent-server:b481a038f6e9924eef4a66d04fdbedda95ecd915-python-arm64
ghcr.io/openhands/agent-server:require-secret-key-python-arm64
ghcr.io/openhands/agent-server:b481a03-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim-arm64
ghcr.io/openhands/agent-server:b481a03-golang
ghcr.io/openhands/agent-server:b481a038f6e9924eef4a66d04fdbedda95ecd915-golang
ghcr.io/openhands/agent-server:require-secret-key-golang
ghcr.io/openhands/agent-server:b481a03-golang_tag_1.21-bookworm
ghcr.io/openhands/agent-server:b481a03-java
ghcr.io/openhands/agent-server:b481a038f6e9924eef4a66d04fdbedda95ecd915-java
ghcr.io/openhands/agent-server:require-secret-key-java
ghcr.io/openhands/agent-server:b481a03-eclipse-temurin_tag_17-jdk
ghcr.io/openhands/agent-server:b481a03-python
ghcr.io/openhands/agent-server:b481a038f6e9924eef4a66d04fdbedda95ecd915-python
ghcr.io/openhands/agent-server:require-secret-key-python
ghcr.io/openhands/agent-server:b481a03-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim

About Multi-Architecture Support

  • Each variant tag (e.g., b481a03-python) is a multi-arch manifest supporting both amd64 and arm64
  • Docker automatically pulls the correct architecture for your platform
  • Individual architecture tags (e.g., b481a03-python-amd64) are also available if needed

… persistence

Adds an opt-in Config.require_secret_key flag (env OH_REQUIRE_SECRET_KEY).
When set, FileSettingsStore/FileSecretsStore raise MissingCipherError
instead of silently downgrading to plaintext storage when secrets are
present and no OH_SECRET_KEY cipher is configured. Off by default, so
the zero-config self-hosted/OSS path is unchanged.

Fixes #4609.
@github-actions

github-actions Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Python API breakage checks — ✅ PASSED

Result:PASSED

Action log

@github-actions

github-actions Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

REST API breakage checks (OpenAPI) — ✅ PASSED

Result:PASSED

Action log

@github-actions

github-actions Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Coverage

Coverage Report •
FileStmtsMissCoverMissing
openhands-agent-server/openhands/agent_server/persistence
   models.py2192788%53, 337, 342, 380, 428, 468–474, 476, 478, 481, 485, 511, 528–529, 574, 578, 580, 609–612, 615
   store.py4346984%44–45, 95, 99, 103, 107, 111, 117, 140–141, 163, 166–169, 171–172, 177, 214, 216–220, 222–226, 231–236, 340–341, 346, 348–349, 459, 485–486, 491, 493–494, 502, 563–564, 641, 643, 655–656, 661, 665, 683–684, 689, 753–754, 758–760, 779, 809–810, 812, 911, 975
TOTAL414171726458% 

@simonrosenberg
simonrosenberg marked this pull request as draft August 24, 2026 15:34
@simonrosenberg
simonrosenberg marked this pull request as ready for review August 24, 2026 15:49

all-hands-bot commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator

Review complete.

This review was performed through OpenHands Cloud Automation. You can log in and view the conversation here.

@all-hands-bot all-hands-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Acceptable — clean design, correct implementation, a couple of symmetric test gaps worth filling.

The config field → helper → store-constructor wiring is consistent and readable. All write paths (save, set_secret, delete_secret via _save_with_versions) properly funnel through the require_cipher guard. The update() path on FileSettingsStore inherits the check automatically because it delegates to save(). The flag is off by default so existing deployments are unaffected. Error messages are actionable.

Two symmetric tests are missing (details in inline comments), and one docstring is pure noise. Otherwise good.

[TESTING GAPS]

  • tests/agent_server/test_persistence_require_cipher.py — two symmetric FileSecretsStore coverage holes (see inline).

[STYLE NOTE]

  • openhands-agent-server/openhands/agent_server/persistence/store.py, line 854 — redundant docstring (see inline).

[RISK ASSESSMENT]

  • [Overall PR] ⚠️ Risk Assessment: 🟢 LOW
    Default is False; no behaviour change for any existing deployment. When enabled, the guard raises immediately with a clear, actionable message before writing anything. All secret write paths are covered by the same _save_with_versions funnel.

VERDICT:
Worth merging — functionally correct and safe. The two missing tests are worth adding before or just after merge but are not blockers.

KEY INSIGHT:
The security contract (raise before writing, never silently store plaintext when the flag is on) is correctly enforced on every write path; the only gap is symmetric backward-compat test coverage for FileSecretsStore.

This review was generated by an AI agent (OpenHands) on behalf of the user through OpenHands Automation. View conversation


Improve this review? If any feedback above seems incorrect or irrelevant to this repository, you can teach the reviewer to do better:

  1. Add a .agents/skills/custom-codereview-guide.md file to your branch (or edit it if one already exists) with the /codereview trigger and the context the reviewer is missing. See the customization docs for the required frontmatter format.
  2. Re-request a review — the reviewer reads guidelines from the PR branch, so your changes take effect immediately.
  3. When your PR is merged, the guideline file goes through normal code review by repository maintainers.

Resolve with AI? Install the iterate skill in your agent and run /iterate to automatically drive this PR through CI, review, and QA until it's merge-ready.

Was this review helpful? React with 👍 or 👎 to give feedback.

Comment thread tests/agent_server/test_persistence_require_cipher.py Outdated
Comment thread openhands-agent-server/openhands/agent_server/persistence/store.py Outdated
- Add symmetric FileSecretsStore coverage for the two backward-compat
  cases (no-secrets no-op, default-off plaintext), mirroring the
  existing FileSettingsStore tests.
- Drop the redundant _get_require_cipher docstring — it restated the
  signature verbatim.
@simonrosenberg

Copy link
Copy Markdown
Member Author

Addressed both review points in fad27ba: added the two symmetric FileSecretsStore tests and dropped the redundant docstring on _get_require_cipher. Ready for another look.

all-hands-bot commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator

Review complete.

This review was performed through OpenHands Cloud Automation. You can log in and view the conversation here.

@all-hands-bot all-hands-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Acceptable — Clean, minimal, well-tested opt-in guard. One improvement opportunity worth a follow-up.

Summary: This PR adds require_secret_key (OH_REQUIRE_SECRET_KEY) as an opt-in boolean flag that makes FileSettingsStore / FileSecretsStore raise MissingCipherError instead of silently falling back to plaintext when OH_SECRET_KEY is absent. The design is correct — default-off preserves the zero-config OSS path, and the guard is wired through both stores symmetrically. 8 tests exercise the real code paths across all combinations.

One gap worth a follow-up: The settings guard checks llm_api_key_is_set (LLM api_key field only). OpenHandsAgentSettings also has critic_api_key: str | SecretStr | None with its own serialize_secret serializer. A user with only critic_api_key set and require_cipher=True will silently get plaintext output — the guard won't fire. See inline comment for details. This matches the existing warning gate, so it's a pre-existing design boundary, but widening it here would make the guarantee complete.

[RISK ASSESSMENT]

  • [Overall PR] ⚠️ Risk Assessment: 🟢 LOW
    Pure opt-in feature with default=False. Default-off means zero behavior change for existing deployments. The only code that executes on the new path is an early raise inside an already-existing if not cipher: branch — no new state, no new I/O. Test coverage is solid and the backward-compat test is explicitly preserved.

VERDICT:
Worth merging — core logic is sound, the critic_api_key gap is a narrow pre-existing edge case that can be addressed in a follow-up once the enterprise opt-in wiring lands.

KEY INSIGHT: The secret-presence gate (llm_api_key_is_set / custom_secrets) is inherited from the existing warning path — broadening it to cover all SecretStr fields would make require_cipher airtight.

This review was generated by an AI agent (OpenHands) on behalf of the user through OpenHands Automation. View conversation


Improve this review? If any feedback above seems incorrect or irrelevant to this repository, you can teach the reviewer to do better:

  1. Add a .agents/skills/custom-codereview-guide.md file to your branch (or edit it if one already exists) with the /codereview trigger and the context the reviewer is missing. See the customization docs for the required frontmatter format.
  2. Re-request a review - the reviewer reads guidelines from the PR branch, so your changes take effect immediately.
  3. When your PR is merged, the guideline file goes through normal code review by repository maintainers.

Resolve with AI? Install the iterate skill in your agent and run /iterate to automatically drive this PR through CI, review, and QA until it's merge-ready.

Was this review helpful? React with 👍 or 👎 to give feedback.

Comment thread openhands-agent-server/openhands/agent_server/persistence/store.py Outdated
PersistedSettings.has_any_secret covers both llm.api_key and
verification.critic_api_key (a separate secret field on
OpenHandsAgentSettings). Without this, a user who only set
critic_api_key could still get plaintext storage even with
require_cipher=True.
@simonrosenberg

Copy link
Copy Markdown
Member Author

Addressed the critic_api_key gap in 6ee2092. Ready for another look.

all-hands-bot commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator

Review complete.

This review was performed through OpenHands Cloud Automation. You can log in and view the conversation here.

@all-hands-bot all-hands-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Taste Rating: Needs improvement — the direction is sound, but the secret-detection predicate still has security-sensitive blind spots.

[CRITICAL ISSUES]

  • [openhands-agent-server/openhands/agent_server/persistence/models.py, Line 184] Security/Correctness: has_any_secret only checks the LLM API key and critic API key. Other settings fields serialize secrets too (agent_context.secrets, MCP env/headers, header auth credentials, OAuth token state), so require_cipher=True can still fall through to plaintext persistence for those settings.

[TESTING GAPS]

  • Add regression coverage for FileSettingsStore(..., require_cipher=True) with no cipher when agent_context.secrets is populated, and for at least one MCP secret-bearing field. Those tests should fail today because the guard returns False and the store writes plaintext.

[RISK ASSESSMENT]

  • [Overall PR] ⚠️ Risk Assessment: 🟡 MEDIUM
    This PR touches security-sensitive secret persistence. The default behavior remains unchanged, but the new opt-in guard can give multi-tenant deployments a false guarantee while still allowing some settings secrets to be persisted in plaintext.

VERDICT:
Needs rework: the require_secret_key guard needs to cover every settings secret serializer, not just LLM/critic keys.

KEY INSIGHT:
The store should reuse the existing serialization pipeline to detect secret-bearing settings instead of hand-maintaining a partial list of secret fields.


Improve this review? If any feedback above seems incorrect or irrelevant to this repository, you can teach the reviewer to do better:

  1. Add a .agents/skills/custom-codereview-guide.md file to your branch (or edit it if one already exists) with the /codereview trigger and the context the reviewer is missing (e.g., "Security concerns about X do not apply here because Y"). See the customization docs for the required frontmatter format.
  2. Re-request a review - the reviewer reads guidelines from the PR branch, so your changes take effect immediately.
  3. When your PR is merged, the guideline file goes through normal code review by repository maintainers.

Resolve with AI? Install the iterate skill in your agent and run /iterate to automatically drive this PR through CI, review, and QA until it's merge-ready.

Was this review helpful? React with 👍 or 👎 to give feedback.

This review was generated by an AI agent (OpenHands) on behalf of the user through OpenHands Automation. View conversation

Comment thread openhands-agent-server/openhands/agent_server/persistence/models.py
…checklist

has_any_secret previously checked only llm.api_key + critic_api_key by
name -- MCP server env/headers, agent_context.secrets, and any future
secret-bearing field would silently fall through require_cipher=True
to plaintext storage.

Replaced with a probe cipher passed as context={"cipher": probe} to
force every secret field's own serializer down its real 'encrypted'
branch (see resolve_expose_mode), so detection reuses the actual
serialization logic instead of hand-walking the model for SecretStr
instances -- which also can't see fields like AgentContext.secrets,
whose bare-string values are plain str at rest and only become
secret-shaped inside their own field serializer at dump time.

Added regression tests for MCP env secrets and agent_context.secrets.
@simonrosenberg

Copy link
Copy Markdown
Member Author

Addressed the structural gap in 091ceb5 — has_any_secret now reuses the real serialization pipeline (probe cipher forcing every field's own serializer) instead of a hand-maintained checklist, so it generalizes to MCP secrets, agent_context.secrets, and any future secret-bearing field. Ready for another look.

all-hands-bot commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator

Review complete.

This review was performed through OpenHands Cloud Automation. You can log in and view the conversation here.

@all-hands-bot all-hands-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Good taste — Elegant, focused solution that fixes a real gap in secret detection coverage.


What this does

Previously, the plaintext-storage guard only checked llm_api_key_is_set (settings) and custom_secrets non-empty (secrets), meaning MCP server env/headers, critic_api_key, agent_context.secrets, and any future secret-bearing fields were silently persisted in plaintext with no warning. This PR:

  1. Broadens detection via has_any_secret, which probes the real Pydantic serialization pipeline instead of field-type inspection — correctly catching AgentContext.secrets whose values are plain str at rest and only become secret-shaped inside their field serializer.
  2. Adds require_secret_key config flag (default False) that turns the warning into a hard MissingCipherError raise, intended for multi-tenant / production deployments.
  3. Tests all the edge cases: critic key, MCP env, agent context secrets, empty-secrets bypass, cipher round-trips, backward compat.

One minor observation

In the no-cipher path, has_any_secret runs model_dump (probe) before save() runs model_dump again for the actual write — two serializations. Harmless given the filesystem I/O that follows, but worth noting if this path ever becomes latency-sensitive.

The PR description is empty, which makes it harder to understand the motivation from the PR UI, but the code and test comments are self-documenting enough.


[RISK ASSESSMENT]

  • [Overall PR] ⚠️ Risk Assessment: 🟢 LOW
    require_secret_key defaults to False, so all existing behavior is fully preserved. The only behavioral difference for existing deployments is that the plaintext warning now fires for more secret types (MCP env vars, critic key, agent context secrets) — a strictly safer default. The MissingCipherError path is entirely opt-in.

VERDICT:
Worth merging — Clean implementation, solid tests, backward-compatible.

KEY INSIGHT:
Using the real serialization pipeline as a probe (a fake cipher that sets a flag on encrypt()) is the right design: it guarantees coverage over all current and future secret-bearing fields without maintaining a fragile field checklist.


Improve this review? If any feedback above seems incorrect or irrelevant to this repository, you can teach the reviewer to do better:

  1. Add a .agents/skills/custom-codereview-guide.md file to your branch (or edit it if one already exists) with the /codereview trigger and the context the reviewer is missing. See the customization docs for the required frontmatter format.
  2. Re-request a review — the reviewer reads guidelines from the PR branch, so your changes take effect immediately.
  3. When your PR is merged, the guideline file goes through normal code review by repository maintainers.

Resolve with AI? Install the iterate skill in your agent and run /iterate to automatically drive this PR through CI, review, and QA until it's merge-ready.

Was this review helpful? React with 👍 or 👎 to give feedback.

This review was generated by an AI agent (OpenHands) on behalf of the user through OpenHands Automation. View conversation

all-hands-bot commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator

Review complete.

This review was performed through OpenHands Cloud Automation. You can log in and view the conversation here.

@all-hands-bot all-hands-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Taste Rating: Good taste — this is a focused, pragmatic fix that reuses the real secret serialization path instead of maintaining another drift-prone checklist.

Summary

  • The require_secret_key/require_cipher path is additive and default-off, so existing zero-config OSS/self-hosted behavior is preserved.
  • Secret detection now flows through the same serializers used for persistence, which correctly covers LLM keys, critic keys, MCP secret maps, custom secrets, and agent_context.secrets without adding per-field special cases.
  • The new tests exercise both settings and custom-secret stores, plaintext backward compatibility, cipher-backed round-trips, and previously missed non-LLM secret fields. GitHub CI for the PR is green; I could not run the local pytest suite in this sandbox because the checked-out environment lacks pytest/project dependencies, and I avoided installing them during review.

[RISK ASSESSMENT]

  • [Overall PR] ⚠️ Risk Assessment: 🟡 MEDIUM
    This touches secrets handling and persistence, which is security-sensitive, but the blast radius is constrained: the stricter behavior is opt-in, existing defaults are preserved, no dependencies changed, and the implementation follows the existing Pydantic serializer/cipher pattern with direct regression coverage.

VERDICT:
Worth merging: Core logic is sound, backward-compatible by default, and covered by meaningful regression tests.

KEY INSIGHT:
The probe-cipher approach has good taste because it removes a duplicated notion of “which fields are secrets” and lets the existing serialization data structure own that decision.

This review was generated by an AI agent (OpenHands) on behalf of the user through OpenHands Automation. View conversation

require_secret_key was an opt-in flag that only helps operators who
remember to enable it -- the same failure mode as forgetting
OH_SECRET_KEY itself, so it added a second forgettable switch rather
than closing a gap. Removed: Config.require_secret_key, the
require_cipher constructor params and raise branches on
FileSettingsStore/FileSecretsStore, and _get_require_cipher.

Kept has_any_secret (PersistedSettings/Secrets), which fixes a real
pre-existing bug independent of require_secret_key: the plaintext-save
warning was gated on llm_api_key_is_set alone, so critic_api_key, MCP
server secrets, and agent_context.secrets triggered no warning at all.
Tests renamed/rewritten to cover has_any_secret detection directly and
the now-correct warning behavior, dropping the require_cipher-raise
cases.
@simonrosenberg simonrosenberg changed the title feat(agent-server): add require_secret_key to refuse plaintext secret persistence fix(agent-server): detect all secret-bearing fields for the plaintext-save warning, not just llm.api_key Aug 26, 2026
@simonrosenberg

Copy link
Copy Markdown
Member Author

Simplified in b481a03 — dropped require_secret_key entirely after concluding it's a second forgettable safety switch that doesn't fix the forgettability problem it targets (see updated PR description). What's left is just the has_any_secret bug fix: the pre-existing plaintext-save warning was gated on llm_api_key_is_set alone and missed critic_api_key/MCP secrets/agent_context.secrets entirely. Ready for another look.

all-hands-bot commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator

Review complete.

This review was performed through OpenHands Cloud Automation. You can log in and view the conversation here.

@all-hands-bot all-hands-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Taste Rating: Good taste — focused fix that broadens plaintext-save warnings by reusing the existing secret serialization pipeline.

Summary

  • has_any_secret uses the real Pydantic secret serializers with a probe cipher, avoiding another drift-prone per-field checklist and catching future secret-bearing fields automatically.
  • The store changes are minimal and backward-compatible: plaintext fallback behavior is unchanged; only the warning gate is corrected.
  • Regression coverage hits LLM, critic, MCP, agent-context, custom-secret, no-secret, warning, and cipher-backed round-trip paths. I also ran uv run pytest tests/agent_server/test_persistence_secret_detection.py -q locally: 11 passed.

[RISK ASSESSMENT]

  • [Overall PR] ⚠️ Risk Assessment: 🟡 MEDIUM
    Touches security-sensitive secret persistence/warning logic, but the blast radius is narrow and behavior remains backward-compatible because it only broadens warnings when saving without a cipher.

VERDICT:
Worth merging: Core logic is sound and tested; no blocking code-review issues found.

KEY INSIGHT:
The probe-cipher approach has the right data flow: the same serializers that would persist secrets are now the source of truth for detecting them.

This review was generated by an AI agent (OpenHands) on behalf of the user through OpenHands Automation. View conversation

@simonrosenberg
simonrosenberg merged commit b3bf98e into main Aug 26, 2026
42 of 43 checks passed
@simonrosenberg
simonrosenberg deleted the require-secret-key branch August 26, 2026 11:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

agent-server: plaintext-secret warning misses critic_api_key, MCP secrets, agent_context.secrets

3 participants